mirror of https://github.com/espruino/BangleApps
Fix progress bar - now goes smoothly up over the course of the app upload.
Also tidy it up significantly and reduce duplicationpull/205/head
parent
86ab3706ea
commit
9b918055da
|
@ -129,6 +129,7 @@
|
|||
|
||||
<script src="https://www.puck-js.com/puck.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/comms.js"></script>
|
||||
<script src="js/appinfo.js"></script>
|
||||
<script src="js/index.js"></script>
|
||||
|
|
41
js/comms.js
41
js/comms.js
|
@ -9,14 +9,19 @@ reset : (opt) => new Promise((resolve,reject) => {
|
|||
});
|
||||
}),
|
||||
uploadApp : (app,skipReset) => {
|
||||
Progress.show({title:`Uploading ${app.name}`,sticky:true});
|
||||
return AppInfo.getFiles(app, httpGet).then(fileContents => {
|
||||
return new Promise((resolve,reject) => {
|
||||
console.log("uploadApp",fileContents.map(f=>f.name).join(", "));
|
||||
var maxBytes = fileContents.reduce((b,f)=>b+f.content.length, 0)||1;
|
||||
var currentBytes = 0;
|
||||
|
||||
// Upload each file one at a time
|
||||
function doUploadFiles() {
|
||||
// No files left - print 'reboot' message
|
||||
if (fileContents.length==0) {
|
||||
Puck.write(`\x10E.showMessage('Hold BTN3\\nto reload')\n`,(result) => {
|
||||
Progress.hide({sticky:true});
|
||||
if (result===null) return reject("");
|
||||
resolve(app);
|
||||
});
|
||||
|
@ -24,17 +29,27 @@ uploadApp : (app,skipReset) => {
|
|||
}
|
||||
var f = fileContents.shift();
|
||||
console.log(`Upload ${f.name} => ${JSON.stringify(f.content)}`);
|
||||
Progress.show({
|
||||
min:currentBytes / maxBytes,
|
||||
max:(currentBytes+f.content.length) / maxBytes});
|
||||
currentBytes += f.content.length;
|
||||
// Chould check CRC here if needed instead of returning 'OK'...
|
||||
// E.CRC32(require("Storage").read(${JSON.stringify(app.name)}))
|
||||
Puck.write(`\x10${f.cmd};Bluetooth.println("OK")\n`,(result) => {
|
||||
if (!result || result.trim()!="OK") return reject("Unexpected response "+(result||""));
|
||||
if (!result || result.trim()!="OK") {
|
||||
Progress.hide({sticky:true});
|
||||
return reject("Unexpected response "+(result||""));
|
||||
}
|
||||
doUploadFiles();
|
||||
}, true); // wait for a newline
|
||||
}
|
||||
// Start the upload
|
||||
function doUpload() {
|
||||
Puck.write(`\x10E.showMessage('Uploading\\n${app.id}...')\n`,(result) => {
|
||||
if (result===null) return reject("");
|
||||
if (result===null) {
|
||||
Progress.hide({sticky:true});
|
||||
return reject("");
|
||||
}
|
||||
doUploadFiles();
|
||||
});
|
||||
}
|
||||
|
@ -48,10 +63,15 @@ uploadApp : (app,skipReset) => {
|
|||
});
|
||||
},
|
||||
getInstalledApps : () => {
|
||||
Progress.show({title:`Getting app list...`,sticky:true});
|
||||
return new Promise((resolve,reject) => {
|
||||
Puck.write("\x03",(result) => {
|
||||
if (result===null) return reject("");
|
||||
if (result===null) {
|
||||
Progress.hide({sticky:true});
|
||||
return reject("");
|
||||
}
|
||||
Puck.eval('require("Storage").list(/\.info$/).map(f=>{var j=require("Storage").readJSON(f,1)||{};j.id=f.slice(0,-5);return j})', (appList,err) => {
|
||||
Progress.hide({sticky:true});
|
||||
if (appList===null) return reject(err || "");
|
||||
console.log("getInstalledApps", appList);
|
||||
resolve(appList);
|
||||
|
@ -60,6 +80,7 @@ getInstalledApps : () => {
|
|||
});
|
||||
},
|
||||
removeApp : app => { // expects an app structure
|
||||
Progress.show({title:`Removing ${app.name}`,sticky:true});
|
||||
var storage = [{name:app.id+".info"}].concat(app.storage);
|
||||
var cmds = storage.map(file=>{
|
||||
return `\x10require("Storage").erase(${toJS(file.name)});\n`;
|
||||
|
@ -67,15 +88,21 @@ removeApp : app => { // expects an app structure
|
|||
console.log("removeApp", cmds);
|
||||
return Comms.reset().then(new Promise((resolve,reject) => {
|
||||
Puck.write(`\x03\x10E.showMessage('Erasing\\n${app.id}...')${cmds}\x10E.showMessage('Hold BTN3\\nto reload')\n`,(result) => {
|
||||
Progress.hide({sticky:true});
|
||||
if (result===null) return reject("");
|
||||
resolve();
|
||||
});
|
||||
}));
|
||||
})).catch(function(reason) {
|
||||
Progress.hide({sticky:true});
|
||||
return Promise.reject(reason);
|
||||
});
|
||||
},
|
||||
removeAllApps : () => {
|
||||
Progress.show({title:"Removing all apps",progess:"animate",sticky:true});
|
||||
return new Promise((resolve,reject) => {
|
||||
// Use write with newline here so we wait for it to finish
|
||||
Puck.write('\x10E.showMessage("Erasing...");require("Storage").eraseAll();Bluetooth.println("OK");reset()\n', (result,err) => {
|
||||
Progress.hide({sticky:true});
|
||||
if (!result || result.trim()!="OK") return reject(err || "");
|
||||
resolve();
|
||||
}, true /* wait for newline */);
|
||||
|
@ -171,10 +198,10 @@ readStorageFile : (filename) => { // StorageFiles are different to normal storag
|
|||
fileContent = fileContent.substr(newLineIdx+1);
|
||||
}
|
||||
} else {
|
||||
showProgress(undefined,100*fileContent.length / (fileSize||1000000));
|
||||
Progress.show({percent:100*fileContent.length / (fileSize||1000000)});
|
||||
}
|
||||
if (finished) {
|
||||
hideProgress();
|
||||
Progress.hide();
|
||||
connection.received = "";
|
||||
connection.cb = undefined;
|
||||
resolve(fileContent);
|
||||
|
@ -188,7 +215,7 @@ readStorageFile : (filename) => { // StorageFiles are different to normal storag
|
|||
while (l!==undefined) { Bluetooth.print(l); l = f.readLine(); }
|
||||
Bluetooth.print("\xFF");
|
||||
})()\n`,() => {
|
||||
showProgress(`Reading ${JSON.stringify(filename)}`,0);
|
||||
Progress.show({title:`Reading ${JSON.stringify(filename)}`,percent:0});
|
||||
console.log(`StorageFile read started...`);
|
||||
});
|
||||
});
|
||||
|
|
144
js/index.js
144
js/index.js
|
@ -14,119 +14,7 @@ httpGet("apps.json").then(apps=>{
|
|||
refreshFilter();
|
||||
});
|
||||
|
||||
// Status
|
||||
// =========================================== Top Navigation
|
||||
function showToast(message, type) {
|
||||
// toast-primary, toast-success, toast-warning or toast-error
|
||||
var style = "toast-primary";
|
||||
if (type=="success") style = "toast-success";
|
||||
else if (type=="error") style = "toast-error";
|
||||
else if (type!==undefined) console.log("showToast: unknown toast "+type);
|
||||
var toastcontainer = document.getElementById("toastcontainer");
|
||||
var msgDiv = htmlElement(`<div class="toast ${style}"></div>`);
|
||||
msgDiv.innerHTML = message;
|
||||
toastcontainer.append(msgDiv);
|
||||
setTimeout(function() {
|
||||
msgDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
var progressToast; // the DOM element
|
||||
var progressSticky; // showProgress(,,"sticky") don't remove until hideProgress("sticky")
|
||||
var progressInterval; // the interval used if showProgress(..., "animate")
|
||||
var progressPercent; // the current progress percentage
|
||||
function showProgress(text, percent, sticky) {
|
||||
if (sticky=="sticky")
|
||||
progressSticky = true;
|
||||
if (!progressToast) {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = undefined;
|
||||
}
|
||||
if (percent == "animate") {
|
||||
progressInterval = setInterval(function() {
|
||||
progressPercent += 2;
|
||||
if (progressPercent>100) progressPercent=0;
|
||||
showProgress(undefined, progressPercent);
|
||||
}, 100);
|
||||
percent = 0;
|
||||
}
|
||||
progressPercent = percent;
|
||||
|
||||
var toastcontainer = document.getElementById("toastcontainer");
|
||||
progressToast = htmlElement(`<div class="toast">
|
||||
${text ? `<div>${text}</div>`:``}
|
||||
<div class="bar bar-sm">
|
||||
<div class="bar-item" id="progressToast" role="progressbar" style="width:${percent}%;" aria-valuenow="${percent}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>`);
|
||||
toastcontainer.append(progressToast);
|
||||
} else {
|
||||
var pt=document.getElementById("progressToast");
|
||||
pt.setAttribute("aria-valuenow",percent);
|
||||
pt.style.width = percent+"%";
|
||||
}
|
||||
}
|
||||
function hideProgress(sticky) {
|
||||
if (progressSticky && sticky!="sticky")
|
||||
return;
|
||||
progressSticky = false;
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = undefined;
|
||||
}
|
||||
if (progressToast) progressToast.remove();
|
||||
progressToast = undefined;
|
||||
}
|
||||
|
||||
Puck.writeProgress = function(charsSent, charsTotal) {
|
||||
if (charsSent===undefined) {
|
||||
hideProgress();
|
||||
return;
|
||||
}
|
||||
var percent = Math.round(charsSent*100/charsTotal);
|
||||
showProgress(undefined, percent);
|
||||
}
|
||||
function showPrompt(title, text, buttons) {
|
||||
if (!buttons) buttons={yes:1,no:1};
|
||||
return new Promise((resolve,reject) => {
|
||||
var modal = htmlElement(`<div class="modal active">
|
||||
<!--<a href="#close" class="modal-overlay" aria-label="Close"></a>-->
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<a href="#close" class="btn btn-clear float-right" aria-label="Close"></a>
|
||||
<div class="modal-title h5">${escapeHtml(title)}</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content">
|
||||
${escapeHtml(text).replace(/\n/g,'<br/>')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer">
|
||||
${buttons.yes?'<button class="btn btn-primary" isyes="1">Yes</button>':''}
|
||||
${buttons.no?'<button class="btn" isyes="0">No</button>':''}
|
||||
${buttons.ok?'<button class="btn" isyes="1">Ok</button>':''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
document.body.append(modal);
|
||||
modal.querySelector("a[href='#close']").addEventListener("click",event => {
|
||||
event.preventDefault();
|
||||
reject("User cancelled");
|
||||
modal.remove();
|
||||
});
|
||||
htmlToArray(modal.getElementsByTagName("button")).forEach(button => {
|
||||
button.addEventListener("click",event => {
|
||||
event.preventDefault();
|
||||
var isYes = event.target.getAttribute("isyes")=="1";
|
||||
if (isYes) resolve();
|
||||
else reject("User cancelled");
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
function showChangeLog(appid) {
|
||||
var app = appNameToApp(appid);
|
||||
function show(contents) {
|
||||
|
@ -170,12 +58,11 @@ function handleCustomApp(appTemplate) {
|
|||
Object.keys(appFiles).forEach(k => app[k] = appFiles[k]);
|
||||
console.log("Received custom app", app);
|
||||
modal.remove();
|
||||
showProgress(`Uploading ${app.name}`,undefined,"sticky");
|
||||
Comms.uploadApp(app).then(()=>{
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
resolve();
|
||||
}).catch(e => {
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
reject(e);
|
||||
});
|
||||
}, false);
|
||||
|
@ -334,9 +221,8 @@ function refreshLibrary() {
|
|||
// upload
|
||||
icon.classList.remove("icon-upload");
|
||||
icon.classList.add("loading");
|
||||
showProgress(`Uploading ${app.name}`,undefined,"sticky");
|
||||
Comms.uploadApp(app).then((appJSON) => {
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
if (appJSON) appsInstalled.push(appJSON);
|
||||
showToast(app.name+" Uploaded!", "success");
|
||||
icon.classList.remove("loading");
|
||||
|
@ -344,7 +230,7 @@ function refreshLibrary() {
|
|||
refreshMyApps();
|
||||
refreshLibrary();
|
||||
}).catch(err => {
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
showToast("Upload failed, "+err, "error");
|
||||
icon.classList.remove("loading");
|
||||
icon.classList.add("icon-upload");
|
||||
|
@ -403,19 +289,16 @@ function customApp(app) {
|
|||
|
||||
function updateApp(app) {
|
||||
if (app.custom) return customApp(app);
|
||||
showProgress(`Upgrading ${app.name}`,undefined,"sticky");
|
||||
return Comms.removeApp(app).then(()=>{
|
||||
showToast(app.name+" removed successfully. Updating...",);
|
||||
appsInstalled = appsInstalled.filter(a=>a.id!=app.id);
|
||||
return Comms.uploadApp(app);
|
||||
}).then((appJSON) => {
|
||||
hideProgress("sticky");
|
||||
if (appJSON) appsInstalled.push(appJSON);
|
||||
showToast(app.name+" Updated!", "success");
|
||||
refreshMyApps();
|
||||
refreshLibrary();
|
||||
}, err=>{
|
||||
hideProgress("sticky");
|
||||
showToast(app.name+" update failed, "+err,"error");
|
||||
refreshMyApps();
|
||||
refreshLibrary();
|
||||
|
@ -488,18 +371,15 @@ return `<div class="tile column col-6 col-sm-12 col-xs-12">
|
|||
|
||||
function getInstalledApps() {
|
||||
showLoadingIndicator("myappscontainer");
|
||||
showProgress(`Getting app list...`,undefined,"sticky");
|
||||
// Get apps and files
|
||||
return Comms.getInstalledApps()
|
||||
.then(appJSON => {
|
||||
hideProgress("sticky");
|
||||
appsInstalled = appJSON;
|
||||
refreshMyApps();
|
||||
refreshLibrary();
|
||||
})
|
||||
.then(() => handleConnectionChange(true))
|
||||
.catch(err=>{
|
||||
hideProgress("sticky");
|
||||
return Promise.reject();
|
||||
});
|
||||
}
|
||||
|
@ -555,15 +435,14 @@ document.getElementById("settime").addEventListener("click",event=>{
|
|||
});
|
||||
document.getElementById("removeall").addEventListener("click",event=>{
|
||||
showPrompt("Remove All","Really remove all apps?").then(() => {
|
||||
showProgress("Removing all apps","animate", "sticky");
|
||||
return Comms.removeAllApps();
|
||||
}).then(()=>{
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
appsInstalled = [];
|
||||
showToast("All apps removed","success");
|
||||
return getInstalledApps();
|
||||
}).catch(err=>{
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
showToast("App removal failed, "+err,"error");
|
||||
});
|
||||
});
|
||||
|
@ -578,24 +457,23 @@ document.getElementById("installdefault").addEventListener("click",event=>{
|
|||
appCount = defaultApps.length;
|
||||
return showPrompt("Install Defaults","Remove everything and install default apps?");
|
||||
}).then(() => {
|
||||
showProgress("Removing all apps","animate", "sticky");
|
||||
return Comms.removeAllApps();
|
||||
}).then(()=>{
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
appsInstalled = [];
|
||||
showToast(`Existing apps removed. Installing ${appCount} apps...`);
|
||||
return new Promise((resolve,reject) => {
|
||||
function upload() {
|
||||
var app = defaultApps.shift();
|
||||
if (app===undefined) return resolve();
|
||||
showProgress(`${app.name} (${appCount-defaultApps.length}/${appCount})`,undefined,"sticky");
|
||||
Progress.show({title:`${app.name} (${appCount-defaultApps.length}/${appCount})`,sticky:true});
|
||||
Comms.uploadApp(app,"skip_reset").then((appJSON) => {
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
if (appJSON) appsInstalled.push(appJSON);
|
||||
showToast(`(${appCount-defaultApps.length}/${appCount}) ${app.name} Uploaded`);
|
||||
upload();
|
||||
}).catch(function() {
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
reject()
|
||||
});
|
||||
}
|
||||
|
@ -607,7 +485,7 @@ document.getElementById("installdefault").addEventListener("click",event=>{
|
|||
showToast("Default apps successfully installed!","success");
|
||||
return getInstalledApps();
|
||||
}).catch(err=>{
|
||||
hideProgress("sticky");
|
||||
Progress.hide({sticky:true});
|
||||
showToast("App Install failed, "+err,"error");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
// General UI tools (progress bar, toast, prompt)
|
||||
|
||||
/// Handle progress bars
|
||||
var Progress = {
|
||||
domElement : null, // the DOM element
|
||||
sticky : false, // Progress.show({..., sticky:true}) don't remove until Progress.hide({sticky:true})
|
||||
interval : undefined, // the interval used if Progress.show({progress:"animate"})
|
||||
percent : undefined, // the current progress percentage
|
||||
min : 0, // scaling for percentage
|
||||
max : 1, // scaling for percentage
|
||||
|
||||
/* Show a Progress message
|
||||
Progress.show({
|
||||
sticky : bool // keep showing text even when Progress.hide is called (unless Progress.hide({sticky:true}))
|
||||
percent : number | "animate"
|
||||
min : // minimum scale for percentage (default 0)
|
||||
max : // maximum scale for percentage (default 1)
|
||||
}) */
|
||||
show : function(options) {
|
||||
options = options||{};
|
||||
var text = options.title;
|
||||
if (options.sticky) Progress.sticky = true;
|
||||
if (options.min!==undefined) Progress.min = options.min;
|
||||
if (options.max!==undefined) Progress.max = options.max;
|
||||
var percent = options.percent;
|
||||
if (percent!==undefined)
|
||||
percent = Progress.min*100 + (Progress.max-Progress.min)*percent;
|
||||
if (!Progress.domElement) {
|
||||
if (Progress.interval) {
|
||||
clearInterval(Progress.interval);
|
||||
Progress.interval = undefined;
|
||||
}
|
||||
if (percent == "animate") {
|
||||
Progress.interval = setInterval(function() {
|
||||
Progress.percent += 2;
|
||||
if (Progress.percent>100) Progress.percent=0;
|
||||
Progress.show({percent:Progress.percent});
|
||||
}, 100);
|
||||
percent = 0;
|
||||
}
|
||||
|
||||
var toastcontainer = document.getElementById("toastcontainer");
|
||||
Progress.domElement = htmlElement(`<div class="toast">
|
||||
${text ? `<div>${text}</div>`:``}
|
||||
<div class="bar bar-sm">
|
||||
<div class="bar-item" id="Progress.domElement" role="progressbar" style="width:${percent}%;" aria-valuenow="${percent}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>`);
|
||||
toastcontainer.append(Progress.domElement);
|
||||
} else {
|
||||
var pt=document.getElementById("Progress.domElement");
|
||||
pt.setAttribute("aria-valuenow",percent);
|
||||
pt.style.width = percent+"%";
|
||||
}
|
||||
},
|
||||
// Progress.hide({sticky:true}) undoes Progress.show({title:"title", sticky:true})
|
||||
hide : function(options) {
|
||||
options = options||{};
|
||||
if (Progress.sticky && !options.sticky)
|
||||
return;
|
||||
Progress.sticky = false;
|
||||
Progress.min = 0;
|
||||
Progress.max = 1;
|
||||
if (Progress.interval) {
|
||||
clearInterval(Progress.interval);
|
||||
Progress.interval = undefined;
|
||||
}
|
||||
if (Progress.domElement) Progress.domElement.remove();
|
||||
Progress.domElement = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// Add progress handler so we get nice uploads
|
||||
Puck.writeProgress = function(charsSent, charsTotal) {
|
||||
if (charsSent===undefined) {
|
||||
Progress.hide();
|
||||
return;
|
||||
}
|
||||
var percent = Math.round(charsSent*100/charsTotal);
|
||||
Progress.show({percent: percent});
|
||||
}
|
||||
|
||||
/// Show a 'toast' message for status
|
||||
function showToast(message, type) {
|
||||
// toast-primary, toast-success, toast-warning or toast-error
|
||||
var style = "toast-primary";
|
||||
if (type=="success") style = "toast-success";
|
||||
else if (type=="error") style = "toast-error";
|
||||
else if (type!==undefined) console.log("showToast: unknown toast "+type);
|
||||
var toastcontainer = document.getElementById("toastcontainer");
|
||||
var msgDiv = htmlElement(`<div class="toast ${style}"></div>`);
|
||||
msgDiv.innerHTML = message;
|
||||
toastcontainer.append(msgDiv);
|
||||
setTimeout(function() {
|
||||
msgDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/// Show a yes/no prompt
|
||||
function showPrompt(title, text, buttons) {
|
||||
if (!buttons) buttons={yes:1,no:1};
|
||||
return new Promise((resolve,reject) => {
|
||||
var modal = htmlElement(`<div class="modal active">
|
||||
<!--<a href="#close" class="modal-overlay" aria-label="Close"></a>-->
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<a href="#close" class="btn btn-clear float-right" aria-label="Close"></a>
|
||||
<div class="modal-title h5">${escapeHtml(title)}</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content">
|
||||
${escapeHtml(text).replace(/\n/g,'<br/>')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer">
|
||||
${buttons.yes?'<button class="btn btn-primary" isyes="1">Yes</button>':''}
|
||||
${buttons.no?'<button class="btn" isyes="0">No</button>':''}
|
||||
${buttons.ok?'<button class="btn" isyes="1">Ok</button>':''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
document.body.append(modal);
|
||||
modal.querySelector("a[href='#close']").addEventListener("click",event => {
|
||||
event.preventDefault();
|
||||
reject("User cancelled");
|
||||
modal.remove();
|
||||
});
|
||||
htmlToArray(modal.getElementsByTagName("button")).forEach(button => {
|
||||
button.addEventListener("click",event => {
|
||||
event.preventDefault();
|
||||
var isYes = event.target.getAttribute("isyes")=="1";
|
||||
if (isYes) resolve();
|
||||
else reject("User cancelled");
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue