implemented tokens; alpha testing go
This commit is contained in:
parent
4ba6c9315f
commit
e7081a5320
9 changed files with 218 additions and 96 deletions
|
@ -231,8 +231,6 @@ func (self *GameTableServer) writeToDB(tableMsg *models.TableMessage) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableMsg.Token.X = nil
|
||||
tableMsg.Token.Y = nil
|
||||
} else if t.X != nil && t.Y != nil {
|
||||
err := self.dbAdapter.MoveToken(key, t)
|
||||
if err != nil {
|
||||
|
@ -273,11 +271,6 @@ func (self *GameTableServer) writeToDB(tableMsg *models.TableMessage) error {
|
|||
}
|
||||
if tableMsg.Token != nil {
|
||||
t := tableMsg.Token
|
||||
strId := ""
|
||||
if t.Id != nil {
|
||||
strId = *t.Id
|
||||
}
|
||||
fmt.Println(strId +"::" + t.Name + "::" + t.Sprite)
|
||||
if t.Id == nil {
|
||||
id, err := self.dbAdapter.CreateToken(key, *t)
|
||||
t.Id = &id
|
||||
|
|
|
@ -25,8 +25,8 @@ type Token struct {
|
|||
H int `json:"h"`
|
||||
OX int `json:"oX"`
|
||||
OY int `json:"oY"`
|
||||
X *int `json:"x"`
|
||||
Y *int `json:"y"`
|
||||
X *float64 `json:"x"`
|
||||
Y *float64 `json:"y"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
|
@ -42,8 +42,8 @@ type Table struct {
|
|||
type TableMessage struct {
|
||||
Auth *string `json:"auth,omitempty"`
|
||||
Key *TableKey `json:"key"`
|
||||
DiceRoll *DiceRoll `json:"diceRoll"`
|
||||
Token *Token `json:"token"`
|
||||
MapImg *string `json:"mapImg"`
|
||||
AuxMsg *string `json:"auxMsg"`
|
||||
DiceRoll *DiceRoll `json:"diceRoll,omitempty"`
|
||||
Token *Token `json:"token,omitempty"`
|
||||
MapImg *string `json:"mapImg,omitempty"`
|
||||
AuxMsg *string `json:"auxMsg,omitempty"`
|
||||
}
|
||||
|
|
|
@ -290,24 +290,21 @@ func (self *DbEngine) GetAuxMessage(table models.TableKey) (string, error) {
|
|||
}
|
||||
|
||||
func (self *DbEngine) CheckToken(table models.TableKey, tokenId string) (bool, bool) {
|
||||
mongoId, err := primitive.ObjectIDFromHex(tokenId)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
tables := self.db.Collection("tables")
|
||||
if tables != nil {
|
||||
result := models.Table{}
|
||||
err := tables.FindOne(self.mkCtx(10), bson.D{
|
||||
{"name", table.Name},
|
||||
{"passcode", table.Passcode},
|
||||
{"tokens", bson.E{"_id", mongoId}},
|
||||
{"tokens._id", tokenId},
|
||||
}).Decode(&result)
|
||||
if err != nil {
|
||||
fmt.Printf("%v", err)
|
||||
return false, false
|
||||
} else {
|
||||
active := false
|
||||
for _, t := range result.Tokens {
|
||||
if *t.Id == tokenId && t.Active {
|
||||
if t.Id != nil && *t.Id == tokenId && t.Active {
|
||||
active = true
|
||||
}
|
||||
}
|
||||
|
@ -344,10 +341,6 @@ func (self *DbEngine) CreateToken(table models.TableKey, token models.Token) (st
|
|||
}
|
||||
|
||||
func (self *DbEngine) ActivateToken(table models.TableKey, tokenId string, active bool) error {
|
||||
mongoId, err := primitive.ObjectIDFromHex(tokenId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tables := self.db.Collection("tables")
|
||||
if tables != nil {
|
||||
var result models.Table
|
||||
|
@ -356,12 +349,10 @@ func (self *DbEngine) ActivateToken(table models.TableKey, tokenId string, activ
|
|||
bson.D{
|
||||
{"name", table.Name},
|
||||
{"passcode", table.Passcode},
|
||||
{"tokens", bson.E{"_id", mongoId}},
|
||||
{"tokens._id", tokenId},
|
||||
},
|
||||
bson.D{
|
||||
{"$set", bson.D{{"tokens.$", bson.D{
|
||||
{"active", active},
|
||||
}}}},
|
||||
{"$set", bson.D{{"tokens.$.active", active}}},
|
||||
},
|
||||
).Decode(&result)
|
||||
return err
|
||||
|
@ -370,10 +361,6 @@ func (self *DbEngine) ActivateToken(table models.TableKey, tokenId string, activ
|
|||
}
|
||||
|
||||
func (self *DbEngine) MoveToken(table models.TableKey, token models.Token) error {
|
||||
mongoId, err := primitive.ObjectIDFromHex(*token.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tables := self.db.Collection("tables")
|
||||
if tables != nil {
|
||||
var result models.Table
|
||||
|
@ -382,13 +369,10 @@ func (self *DbEngine) MoveToken(table models.TableKey, token models.Token) error
|
|||
bson.D{
|
||||
{"name", table.Name},
|
||||
{"passcode", table.Passcode},
|
||||
{"tokens", bson.E{"_id", mongoId}},
|
||||
{"tokens._id", token.Id},
|
||||
},
|
||||
bson.D{
|
||||
{"$set", bson.D{{"tokens.$", bson.D{
|
||||
{"x", token.X},
|
||||
{"y", token.Y},
|
||||
}}}},
|
||||
{"$set", bson.D{{"tokens.$.x", token.X}, {"tokens.$.y", token.Y}}},
|
||||
},
|
||||
).Decode(&result)
|
||||
return err
|
||||
|
@ -397,10 +381,7 @@ func (self *DbEngine) MoveToken(table models.TableKey, token models.Token) error
|
|||
}
|
||||
|
||||
func (self *DbEngine) DestroyToken(table models.TableKey, tokenId string) error {
|
||||
mongoId, err := primitive.ObjectIDFromHex(tokenId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tables := self.db.Collection("tables")
|
||||
if tables != nil {
|
||||
var result models.Table
|
||||
|
@ -411,7 +392,7 @@ func (self *DbEngine) DestroyToken(table models.TableKey, tokenId string) error
|
|||
{"passcode", table.Passcode},
|
||||
},
|
||||
bson.D{
|
||||
{"$pull", bson.D{{"tokens", bson.D{{"_id", mongoId}}}}},
|
||||
{"$pull", bson.D{{"tokens", bson.D{{"_id", tokenId}}}}},
|
||||
},
|
||||
).Decode(&result)
|
||||
return err
|
||||
|
|
|
@ -43,17 +43,17 @@ async function rebindUi(name, pass) {
|
|||
document.getElementById("input_table_pass").value = pass;
|
||||
dial();
|
||||
const table = await res.json()
|
||||
infoHtml = "<a href='#' onclick='getTables()'>← table list</a><br>";
|
||||
infoHtml = "<a href='#' onclick='getTables();return false;'>← table list</a><br>";
|
||||
infoHtml += `<textarea id='auxMsgZone'>${table.auxMsg}</textarea><br><button onclick='publishAuxMsg()'>Set Status</button>`
|
||||
infoHtml += "<button onclick='destroyTable()'>Destroy Table</button><br/>";
|
||||
infoHtml += "<input id='map_img_upload' type='file'/><button onclick='uploadMapImg()'>Upload Map</button><br/>"
|
||||
if (mapImgs.ok) {
|
||||
infoHtml += "<label>Available Maps</label>";
|
||||
const imgs = await mapImgs.json();
|
||||
infoHtml += "<ul>";
|
||||
infoHtml += "<ul class='two_btn_list'>";
|
||||
for (const i of imgs) {
|
||||
const parts = i.split("/");
|
||||
infoHtml += `<li>${parts[parts.length - 1]} <a href="${i}" target="_blank">view</a> <button onclick="sendMapImg('${i}');">Set</button> <button onclick="deleteImg('${i}')">Delete</button></li>\n`;
|
||||
infoHtml += `<li><a href="${i}" target="_blank">${parts[parts.length - 1]}</a> <button onclick="sendMapImg('${i}');">Set</button> <button onclick="deleteImg('${i}')">Delete</button></li>\n`;
|
||||
}
|
||||
infoHtml += "</ul>";
|
||||
} else {
|
||||
|
@ -65,10 +65,10 @@ async function rebindUi(name, pass) {
|
|||
if (tokenImgs.ok) {
|
||||
tokenListHTML += "<label>Available Sprites</label>";
|
||||
const tokens = await tokenImgs.json();
|
||||
tokenListHTML += "<ul>";
|
||||
tokenListHTML += "<ul class='single_btn_list'>";
|
||||
for (const t of tokens) {
|
||||
const parts = t.split("/");
|
||||
tokenListHTML += `<li>${parts[parts.length - 1]} <a href="${t}" target="_blank">view</a> <button onclick="deleteImg('${t}')">Delete</button></li>\n`
|
||||
tokenListHTML += `<li><a href="${t}" target="_blank">${parts[parts.length - 1]}</a> <button onclick="deleteImg('${t}')">Delete</button></li>\n`
|
||||
}
|
||||
tokenListHTML += "</ul>";
|
||||
fillSpriteDropdown(tokens);
|
||||
|
@ -156,6 +156,7 @@ function scaleSpritePreview(source) {
|
|||
}
|
||||
|
||||
function drawTokenOrigin() {
|
||||
if (tokenSpriteDropdown.selectedIndex >= 0) {
|
||||
const img = previewZone.children[0];
|
||||
const x = Number(tokenWidth.value) / Number(tokenCX.value);
|
||||
const y = Number(tokenHeight.value) / Number(tokenCY.value);
|
||||
|
@ -174,6 +175,7 @@ function drawTokenOrigin() {
|
|||
previewZone.appendChild(originImg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reinitializeSpritePreview() {
|
||||
const img = document.createElement("img");
|
||||
|
@ -189,12 +191,9 @@ function reinitializeSpritePreview() {
|
|||
tokenHeight.value = h;
|
||||
scaleSpritePreview();
|
||||
}
|
||||
if (previewZone.children.length) {
|
||||
previewZone.replaceChild(img, previewZone.children[0]);
|
||||
} else {
|
||||
previewZone.innerHTML = "";
|
||||
previewZone.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
function createToken() {
|
||||
const w = Number(tokenWidth.value);
|
||||
|
@ -233,6 +232,46 @@ function createToken() {
|
|||
setErr("All token fields are required");
|
||||
}
|
||||
|
||||
function destroyToken(id) {
|
||||
const existing = tokens.find(t=>t.t.id == id);
|
||||
if (existing) {
|
||||
const self = Object.assign({}, existing.t);
|
||||
self.active = false;
|
||||
self.x = null;
|
||||
self.y = null;
|
||||
sendToken(self);
|
||||
}
|
||||
}
|
||||
|
||||
function previewExistingToken(id) {
|
||||
const existing = tokens.find(t=>t.t.id == id);
|
||||
if (existing) {
|
||||
tokenWidth.value = existing.t.w;
|
||||
tokenHeight.value = existing.t.h;
|
||||
tokenCX.value = existing.t.oX;
|
||||
tokenCY.value = existing.t.oY;
|
||||
tokenName.value = existing.t.name;
|
||||
for (let i = 0; i < tokenSpriteDropdown.options.length; i++) {
|
||||
if (tokenSpriteDropdown.options[i].value == existing.t.sprite) {
|
||||
tokenSpriteDropdown.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
reinitializeSpritePreview();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTokenMasterList() {
|
||||
if (tokenZone) {
|
||||
let tokenMasterListHTML = "<label>Available Tokens</label><br/><ul class='single_btn_list'>";
|
||||
for (const t of tokens) {
|
||||
tokenMasterListHTML += `<li><a href="#" onclick="previewExistingToken('${t.t.id}');return false">${t.t.name}</a><button onclick="destroyToken('${t.t.id}')">Destroy</button></li>\n`;
|
||||
}
|
||||
tokenMasterListHTML += "</ul>";
|
||||
tokenZone.innerHTML = tokenMasterListHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function publishAuxMsg() {
|
||||
const txtArea = document.getElementById("auxMsgZone");
|
||||
if (txtArea != null) {
|
||||
|
@ -248,12 +287,6 @@ function sendToken(t) {
|
|||
publish({token: t, auth: adminToken.access_token});
|
||||
}
|
||||
|
||||
function revokeToken(t) {
|
||||
t.x = null;
|
||||
t.y = null;
|
||||
sendToken(t);
|
||||
}
|
||||
|
||||
async function uploadMapImg() {
|
||||
try {
|
||||
var input = document.getElementById("map_img_upload");
|
||||
|
@ -391,6 +424,7 @@ async function doLogin() {
|
|||
getTables();
|
||||
adminWrapper.style.display="inline";
|
||||
adminZone.style.display = "block";
|
||||
closeErr();
|
||||
} else {
|
||||
setErr("Incorrect credentials");
|
||||
}
|
||||
|
@ -430,11 +464,19 @@ function setTableCreateFormVisible(v) {
|
|||
}
|
||||
|
||||
function setTokenCreateFormVisible(v) {
|
||||
if (createTokenForm) {
|
||||
createTokenForm.style.display = v ? "block" : "none";
|
||||
if (createTokenForm && tokenZone) {
|
||||
if (v) {
|
||||
// clear the form when displaying because we may have values from a preview of an existing token
|
||||
tokenWidth.value = "";
|
||||
tokenHeight.value = "";
|
||||
tokenCX.value = "";
|
||||
tokenCY.value = "";
|
||||
tokenName.value = "";
|
||||
tokenSpriteDropdown.selectedIndex = 0;
|
||||
}
|
||||
if (!v) {
|
||||
// clear the form
|
||||
createTokenForm.style.display = v ? "block" : "none";
|
||||
tokenZone.style.display = v ? "none" : "inline";
|
||||
reinitializeSpritePreview();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -106,8 +106,8 @@
|
|||
<label>cY<input type="number" id="token_cy" min="0" max="9999" onchange="previewSprite(this)"/></label><br/>
|
||||
<button type="submit" onclick="createToken()">Create</button>
|
||||
<button onclick="setTokenCreateFormVisible(false)">Cancel</button>
|
||||
<div id="tokenPreview_zone"></div>
|
||||
</form>
|
||||
<div id="tokenPreview_zone"></div>
|
||||
<div id="tokenZone"></div>
|
||||
</details><br/>
|
||||
<details id="admin_sprite_win" class="ui_win admin_win"><summary>sprites</summary>
|
||||
|
|
|
@ -19,10 +19,8 @@ function initializeMap(mapImgUrl) {
|
|||
if (init) {
|
||||
map.setView([0,0], 2);
|
||||
}
|
||||
while (tokens.some(t=>t)) {
|
||||
tokens[0].m.removeFrom(map);
|
||||
tokens.shift();
|
||||
}
|
||||
/*
|
||||
*/
|
||||
}
|
||||
|
||||
// this works but assumes the map is square (reasonable limitation I think)
|
||||
|
@ -30,11 +28,58 @@ function resizeMarkers() {
|
|||
tokens.forEach(t=>{
|
||||
const icon = t.m.options.icon;
|
||||
const scaleFactor = mapImg._image.clientWidth / mapImg._image.naturalWidth;
|
||||
icon.options.iconSize = [scaleFactor * t.sz[0], scaleFactor * t.sz[1]];
|
||||
icon.options.iconSize = [scaleFactor * t.t.w, scaleFactor * t.t.h];
|
||||
icon.options.iconAnchor = [scaleFactor * t.t.oX, scaleFactor * t.t.oY];
|
||||
t.m.setIcon(icon);
|
||||
});
|
||||
}
|
||||
|
||||
function processTokens(tokenChanges) {
|
||||
for (const t of tokenChanges) {
|
||||
const i = tokens.findIndex(tk=>tk.t.id == t.id);
|
||||
if (i >= 0) {
|
||||
const self = tokens[i];
|
||||
// token was made active
|
||||
if (t.x != null && t.y != null && !self.t.active && t.active) {
|
||||
self.t.active = true;
|
||||
self.m.addTo(map);
|
||||
// token was made inactive
|
||||
} else if (t.x != null && t.y != null && self.t.active && !t.active) {
|
||||
self.t.active = false;
|
||||
self.m.removeFrom(map);
|
||||
// token was destroyed
|
||||
} else if (t.x == null && t.y == null) {
|
||||
self.m.removeFrom(map);
|
||||
tokens.splice(i, 1);
|
||||
// token was moved
|
||||
} else {
|
||||
self.t.x = t.x;
|
||||
self.t.y = t.y;
|
||||
self.m.setLatLng([t.y, t.x]);
|
||||
}
|
||||
} else {
|
||||
if (t.x != null && t.y != null) {
|
||||
const self = NewToken(t);
|
||||
tokens.push(self);
|
||||
if (t.active) {
|
||||
self.m.addTo(map);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resizeMarkers();
|
||||
}
|
||||
|
||||
function toggleActive(tokenId) {
|
||||
const existing = tokens.find(t=>t.t.id == tokenId);
|
||||
if (existing) {
|
||||
const self = Object.assign({}, existing.t);
|
||||
self.active = !self.active;
|
||||
console.log(self);
|
||||
publish({token: self});
|
||||
}
|
||||
}
|
||||
|
||||
function getCascadingPos() {
|
||||
const topLeft = [0,0];
|
||||
const n = tokens.length;
|
||||
|
@ -43,18 +88,35 @@ function getCascadingPos() {
|
|||
return topLeft;
|
||||
}
|
||||
|
||||
function NewToken(w, h, oX, oY, img, name, x, y) {
|
||||
return {
|
||||
sz: [w, h],
|
||||
m: L.marker((x && y) ? [y,x] : getCascadingPos(), {
|
||||
function moveToken(id) {
|
||||
const existing = tokens.find(t=>t.t.id == id);
|
||||
if (existing) {
|
||||
const self = Object.assign({}, existing.t);
|
||||
const realPos = existing.m.getLatLng();
|
||||
self.x = realPos.lng;
|
||||
self.y = realPos.lat;
|
||||
console.log(self);
|
||||
publish({token: self});
|
||||
}
|
||||
}
|
||||
|
||||
function NewToken(token) {
|
||||
const marker = L.marker([token.y,token.x], {
|
||||
icon: L.icon({
|
||||
iconUrl: img,
|
||||
iconSize: [w,h],
|
||||
iconUrl: token.sprite,
|
||||
iconSize: [token.w,token.h],
|
||||
iconAnchor: [token.oX, token.oY]
|
||||
}),
|
||||
title: name,
|
||||
title: token.name,
|
||||
draggable: true,
|
||||
autoPan: true
|
||||
}),
|
||||
});
|
||||
|
||||
marker.on("moveend", ()=>{moveToken(token.id)});
|
||||
|
||||
return {
|
||||
t: token,
|
||||
m: marker,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -33,9 +33,9 @@ function formatDice(r) {
|
|||
return p;
|
||||
}
|
||||
|
||||
function logDice(dice, many) {
|
||||
function logDice(dice) {
|
||||
const diceLog = document.getElementById("dice_log");
|
||||
if (!many) {
|
||||
if (!Array.isArray(dice)) {
|
||||
dice = [ dice ];
|
||||
} else {
|
||||
if (diceLog) {
|
||||
|
@ -58,19 +58,45 @@ function setAuxMsg(msg) {
|
|||
}
|
||||
}
|
||||
|
||||
function updateTokens(tokens) {
|
||||
// update internal token array and map
|
||||
processTokens(tokens);
|
||||
// update token select window
|
||||
renderTokenSelect();
|
||||
// if admin, update token master list
|
||||
renderTokenMasterList();
|
||||
}
|
||||
|
||||
function renderTokenSelect() {
|
||||
const tokenSelect = document.getElementById("token_select");
|
||||
let tokenSelectHTML = "<ul class='single_btn_list'>";
|
||||
for (const t of tokens) {
|
||||
tokenSelectHTML += `<li><a target="_blank" href="${t.t.sprite}">${t.t.name}</a><button onclick="toggleActive('${t.t.id}')">${(t.t.active ? "Deactivate" : "Activate")}</button></li>\n`;
|
||||
}
|
||||
tokenSelectHTML += "</ul>";
|
||||
tokenSelect.innerHTML = tokenSelectHTML;
|
||||
}
|
||||
|
||||
function makeUpToDate(table) {
|
||||
if (table) {
|
||||
if (table.diceRolls) {
|
||||
logDice(table.diceRolls, true);
|
||||
} else if (table.diceRoll) {
|
||||
logDice(table.diceRoll, false);
|
||||
}
|
||||
// map image has to be set before tokens can be handled!
|
||||
if (table.mapImg) {
|
||||
setMapImg(table.mapImg);
|
||||
}
|
||||
if (table.auxMsg) {
|
||||
setAuxMsg(table.auxMsg);
|
||||
}
|
||||
if (table.diceRolls) {
|
||||
logDice(table.diceRolls);
|
||||
} else if (table.diceRoll) {
|
||||
logDice([table.diceRoll]);
|
||||
}
|
||||
if (table.tokens) {
|
||||
updateTokens(table.tokens);
|
||||
} else if (table.token) {
|
||||
updateTokens([table.token]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,6 +135,10 @@ function dial() {
|
|||
tabletop.style.display = "none";
|
||||
}
|
||||
table = null;
|
||||
while (tokens.some(t=>t)) {
|
||||
tokens[0].m.removeFrom(map);
|
||||
tokens.shift();
|
||||
}
|
||||
}
|
||||
});
|
||||
conn.addEventListener("open", e => {
|
||||
|
@ -126,7 +156,7 @@ function dial() {
|
|||
conn.addEventListener("message", e => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (table == null) {
|
||||
// first fetch comes from mongo, so the rolls array in each diceRoll is a byte array and needs to be decoded
|
||||
// dicerolls are treated as a byte array when marshalling to json, so we have to decode them
|
||||
data.diceRolls.forEach(r=>{
|
||||
r.roll = Uint8Array.from(atob(r.roll), c => c.charCodeAt(0))
|
||||
})
|
||||
|
|
|
@ -24,6 +24,7 @@ body {
|
|||
background: url('./bg.png');
|
||||
background-repeat: repeat;
|
||||
background-attachment: fixed;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
label {
|
||||
|
@ -150,6 +151,7 @@ pre {
|
|||
|
||||
.ui_win ul {
|
||||
max-height: 10em;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#admin_section {
|
||||
|
@ -159,6 +161,9 @@ pre {
|
|||
.admin_win {
|
||||
}
|
||||
|
||||
.admin_win summary {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#map {
|
||||
position:fixed;
|
||||
|
@ -183,3 +188,13 @@ nav {
|
|||
#tokenPreview_zone {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.single_btn_list li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.two_btn_list li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
}
|
|
@ -20,7 +20,6 @@ function saveName() {
|
|||
console.log("saving username");
|
||||
const username = document.getElementById("name_entry");
|
||||
if (username) {
|
||||
console.log(username.value + "input found");
|
||||
document.cookie = "username=" + username.value;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue