more refactor
This commit is contained in:
parent
14569a9415
commit
bb5e2d1fb6
11 changed files with 131 additions and 305 deletions
36
app/app.css
36
app/app.css
|
@ -625,72 +625,72 @@ cyp-queue .current {
|
|||
#fs .info h2 {
|
||||
font-weight: normal;
|
||||
}
|
||||
#playlists header {
|
||||
cyp-playlists header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: var(--spacing);
|
||||
}
|
||||
#playlists header:not([hidden]) {
|
||||
cyp-playlists header:not([hidden]) {
|
||||
display: flex;
|
||||
}
|
||||
#playlists header button {
|
||||
cyp-playlists header button {
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
}
|
||||
#playlists header button .icon {
|
||||
cyp-playlists header button .icon {
|
||||
margin-right: var(--icon-spacing);
|
||||
}
|
||||
#playlists ul {
|
||||
cyp-playlists ul {
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#playlists li {
|
||||
cyp-playlists li {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
#playlists li:not([hidden]) {
|
||||
cyp-playlists li:not([hidden]) {
|
||||
display: flex;
|
||||
}
|
||||
#playlists li .info {
|
||||
cyp-playlists li .info {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
#playlists li .info .icon {
|
||||
cyp-playlists li .info .icon {
|
||||
color: var(--primary);
|
||||
margin-right: var(--icon-spacing);
|
||||
filter: drop-shadow(var(--text-shadow));
|
||||
}
|
||||
#playlists li .info h2 {
|
||||
cyp-playlists li .info h2 {
|
||||
font-size: var(--font-size-large);
|
||||
margin: 0;
|
||||
}
|
||||
#playlists li .info h2,
|
||||
#playlists li .info div {
|
||||
cyp-playlists li .info h2,
|
||||
cyp-playlists li .info div {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#playlists li:not(.has-art) {
|
||||
cyp-playlists li:not(.has-art) {
|
||||
padding: 8px;
|
||||
}
|
||||
#playlists li button .icon {
|
||||
cyp-playlists li button .icon {
|
||||
width: 32px;
|
||||
}
|
||||
#playlists li:nth-child(odd) {
|
||||
cyp-playlists li:nth-child(odd) {
|
||||
background-color: var(--bg-alt);
|
||||
}
|
||||
#playlists .info {
|
||||
cyp-playlists .info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
#playlists .info:not([hidden]) {
|
||||
cyp-playlists .info:not([hidden]) {
|
||||
display: flex;
|
||||
}
|
||||
#playlists .info h2 {
|
||||
cyp-playlists .info h2 {
|
||||
font-weight: normal;
|
||||
}
|
||||
#yt header {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#playlists {
|
||||
cyp-playlists {
|
||||
.component;
|
||||
|
||||
.info {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<cyp-app component="queue" theme="dark" color="dodgerblue">
|
||||
<cyp-app theme="dark" color="dodgerblue">
|
||||
<header>
|
||||
<cyp-player>
|
||||
<span class="art"></span>
|
||||
|
@ -46,9 +46,9 @@
|
|||
</header>
|
||||
<ul></ul>
|
||||
</cyp-queue>
|
||||
<section id="playlists">
|
||||
<cyp-playlists>
|
||||
<ul></ul>
|
||||
</section>
|
||||
</cyp-playlists>
|
||||
<section id="library">
|
||||
<header></header>
|
||||
<ul></ul>
|
||||
|
|
|
@ -14,44 +14,50 @@ import * as yt from "./yt.js";
|
|||
import * as settings from "./settings.js";
|
||||
|
||||
function initIcons() {
|
||||
Array.from(document.querySelectorAll("[data-icon]")).forEach(node => {
|
||||
Array.from(document.querySelectorAll("[data-icon]")).forEach(/** @param {HTMLElement} node */ node => {
|
||||
let icon = html.icon(node.dataset.icon);
|
||||
node.insertBefore(icon, node.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
async function mpdExecutor(resolve, reject) {
|
||||
try {
|
||||
await mpd.init();
|
||||
resolve(mpd);
|
||||
} catch (e) {
|
||||
resolve(mpdMock);
|
||||
console.error(e);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
class App extends HTMLElement {
|
||||
static get observedAttributes() { return ["component"]; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
initIcons();
|
||||
|
||||
this._mpd = new Promise(mpdExecutor);
|
||||
initIcons();
|
||||
|
||||
this._load();
|
||||
}
|
||||
|
||||
get mpd() { return this._mpd; }
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
switch (name) {
|
||||
case "component":
|
||||
const e = new CustomEvent("component-change");
|
||||
this.dispatchEvent(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
await mpd.init();
|
||||
this.mpd = mpd;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.mpd = mpdMock;
|
||||
}
|
||||
|
||||
const promises = ["cyp-player"].map(name => customElements.whenDefined(name));
|
||||
await Promise.all(promises);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("load"));
|
||||
|
||||
const onHashChange = () => {
|
||||
const hash = location.hash.substring(1);
|
||||
this._activate(hash || "queue");
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
onHashChange();
|
||||
}
|
||||
|
@ -59,9 +65,6 @@ class App extends HTMLElement {
|
|||
_activate(what) {
|
||||
location.hash = what;
|
||||
this.setAttribute("component", what);
|
||||
|
||||
const component = this.querySelector(`cyp-${what}`);
|
||||
// component.activate();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,22 @@
|
|||
const APP = "cyp-app";
|
||||
|
||||
export default class Component extends HTMLElement {
|
||||
export class HasApp extends HTMLElement {
|
||||
get _app() { return this.closest("cyp-app"); }
|
||||
get _mpd() { return this._app.mpd; }
|
||||
}
|
||||
|
||||
export default class Component extends HasApp {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._app.then(app => {
|
||||
let mo = new MutationObserver(mrs => {
|
||||
mrs.forEach(mr => this._onAppAttributeChange(mr));
|
||||
});
|
||||
mo.observe(app, {attributes:true});
|
||||
});
|
||||
}
|
||||
|
||||
_onAppAttributeChange(mr) {
|
||||
if (mr.attributeName != "component") { return; }
|
||||
const component = mr.target.getAttribute(mr.attributeName);
|
||||
this._app.addEventListener("load", _ => this._onAppLoad());
|
||||
this._app.addEventListener("component-change", _ => {
|
||||
const component = this._app.getAttribute("component");
|
||||
const isThis = (this.nodeName.toLowerCase() == `cyp-${component}`);
|
||||
this._onComponentChange(component, isThis);
|
||||
});
|
||||
}
|
||||
|
||||
get _app() {
|
||||
return customElements.whenDefined(APP)
|
||||
.then(() => this.closest(APP));
|
||||
}
|
||||
|
||||
get _mpd() {
|
||||
return this._app.then(app => app.mpd);
|
||||
}
|
||||
|
||||
_onComponentChange(component) {}
|
||||
_onComponentChange(_component, _isThis) {}
|
||||
_onAppLoad() {}
|
||||
}
|
||||
|
|
|
@ -1,170 +0,0 @@
|
|||
class Range extends HTMLElement {
|
||||
static get observedAttributes() { return ["min", "max", "value", "step", "disabled"]; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._dom = {};
|
||||
|
||||
this.addEventListener("mousedown", this);
|
||||
this.addEventListener("keydown", this);
|
||||
}
|
||||
|
||||
get _valueAsNumber() {
|
||||
let raw = (this.hasAttribute("value") ? Number(this.getAttribute("value")) : 50);
|
||||
return this._constrain(raw);
|
||||
}
|
||||
get _minAsNumber() {
|
||||
return (this.hasAttribute("min") ? Number(this.getAttribute("min")) : 0);
|
||||
}
|
||||
get _maxAsNumber() {
|
||||
return (this.hasAttribute("max") ? Number(this.getAttribute("max")) : 100);
|
||||
}
|
||||
get _stepAsNumber() {
|
||||
return (this.hasAttribute("step") ? Number(this.getAttribute("step")) : 1);
|
||||
}
|
||||
|
||||
get value() { return String(this._valueAsNumber); }
|
||||
get valueAsNumber() { return this._valueAsNumber; }
|
||||
get min() { return this.hasAttribute("min") ? this.getAttribute("min") : ""; }
|
||||
get max() { return this.hasAttribute("max") ? this.getAttribute("max") : ""; }
|
||||
get step() { return this.hasAttribute("step") ? this.getAttribute("step") : ""; }
|
||||
get disabled() { return this.hasAttribute("disabled"); }
|
||||
|
||||
set _valueAsNumber(value) { this.value = String(value); }
|
||||
set min(min) { this.setAttribute("min", min); }
|
||||
set max(max) { this.setAttribute("max", max); }
|
||||
set value(value) { this.setAttribute("value", value); }
|
||||
set step(step) { this.setAttribute("step", step); }
|
||||
set disabled(disabled) {
|
||||
disabled ? this.setAttribute("disabled", "") : this.removeAttribute("disabled");
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.firstChild) { return; }
|
||||
|
||||
this.innerHTML = `
|
||||
<span class="-track"></span>
|
||||
<span class="-elapsed"></span>
|
||||
<span class="-remaining"></span>
|
||||
<div class="-inner">
|
||||
<button class="-thumb"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Array.from(this.querySelectorAll("[class^='-']")).forEach(node => {
|
||||
let name = node.className.substring(1);
|
||||
this._dom[name] = node;
|
||||
});
|
||||
|
||||
this._update();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
switch (name) {
|
||||
case "min":
|
||||
case "max":
|
||||
case "value":
|
||||
case "step":
|
||||
this._update();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(e) {
|
||||
switch (e.type) {
|
||||
case "mousedown":
|
||||
if (this.disabled) { return; }
|
||||
document.addEventListener("mousemove", this);
|
||||
document.addEventListener("mouseup", this);
|
||||
this._setToMouse(e);
|
||||
break;
|
||||
|
||||
case "mousemove":
|
||||
this._setToMouse(e);
|
||||
break;
|
||||
|
||||
case "mouseup":
|
||||
document.removeEventListener("mousemove", this);
|
||||
document.removeEventListener("mouseup", this);
|
||||
this.dispatchEvent(new CustomEvent("change"));
|
||||
break;
|
||||
|
||||
case "keydown":
|
||||
if (this.disabled) { return; }
|
||||
this._handleKey(e.code);
|
||||
this.dispatchEvent(new CustomEvent("input"));
|
||||
this.dispatchEvent(new CustomEvent("change"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_handleKey(code) {
|
||||
let min = this._minAsNumber;
|
||||
let max = this._maxAsNumber;
|
||||
let range = max - min;
|
||||
let step = this._stepAsNumber;
|
||||
|
||||
switch (code) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
this._valueAsNumber = this._constrain(this._valueAsNumber - step);
|
||||
break;
|
||||
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
this._valueAsNumber = this._constrain(this._valueAsNumber + step);
|
||||
break;
|
||||
|
||||
case "Home": this._valueAsNumber = this._constrain(min); break;
|
||||
case "End": this._valueAsNumber = this._constrain(max); break;
|
||||
|
||||
case "PageUp": this._valueAsNumber = this._constrain(this._valueAsNumber + range/10); break;
|
||||
case "PageDown": this._valueAsNumber = this._constrain(this._valueAsNumber - range/10); break;
|
||||
}
|
||||
}
|
||||
|
||||
_constrain(value) {
|
||||
const min = this._minAsNumber;
|
||||
const max = this._maxAsNumber;
|
||||
const step = this._stepAsNumber;
|
||||
|
||||
value = Math.max(value, min);
|
||||
value = Math.min(value, max);
|
||||
|
||||
value -= min;
|
||||
value = Math.round(value / step) * step;
|
||||
value += min;
|
||||
if (value > max) { value -= step; }
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
_update() {
|
||||
let min = this._minAsNumber;
|
||||
let max = this._maxAsNumber;
|
||||
let frac = (this._valueAsNumber-min) / (max-min);
|
||||
this._dom.thumb.style.left = `${frac * 100}%`;
|
||||
this._dom.remaining.style.left = `${frac * 100}%`;
|
||||
this._dom.elapsed.style.width = `${frac * 100}%`;
|
||||
}
|
||||
|
||||
_setToMouse(e) {
|
||||
let rect = this._dom.inner.getBoundingClientRect();
|
||||
let x = e.clientX;
|
||||
x = Math.max(x, rect.left);
|
||||
x = Math.min(x, rect.right);
|
||||
|
||||
let min = this._minAsNumber;
|
||||
let max = this._maxAsNumber;
|
||||
|
||||
let frac = (x-rect.left) / (rect.right-rect.left);
|
||||
let value = this._constrain(min + frac * (max-min));
|
||||
if (value == this._valueAsNumber) { return; }
|
||||
|
||||
this._valueAsNumber = value;
|
||||
this.dispatchEvent(new CustomEvent("input"));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('x-range', Range);
|
1
app/js/lib/range.js
Symbolic link
1
app/js/lib/range.js
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../../node_modules/custom-range/range.js
|
|
@ -12,6 +12,9 @@ class Player extends Component {
|
|||
this._toggledVolume = 0;
|
||||
this._idleTimeout = null;
|
||||
this._dom = this._initDOM();
|
||||
}
|
||||
|
||||
_onAppLoad() {
|
||||
this._update();
|
||||
}
|
||||
|
||||
|
@ -40,9 +43,8 @@ class Player extends Component {
|
|||
}
|
||||
|
||||
async _command(cmd) {
|
||||
const mpd = await this._mpd;
|
||||
this._clearIdle();
|
||||
const data = await mpd.commandAndStatus(cmd);
|
||||
const data = await this._mpd.commandAndStatus(cmd);
|
||||
this._sync(data);
|
||||
this._idle();
|
||||
}
|
||||
|
@ -57,9 +59,8 @@ class Player extends Component {
|
|||
}
|
||||
|
||||
async _update() {
|
||||
const mpd = await this._mpd;
|
||||
this._clearIdle();
|
||||
const data = await mpd.status();
|
||||
const data = await this._mpd.status();
|
||||
this._sync(data);
|
||||
this._idle();
|
||||
}
|
||||
|
@ -136,10 +137,9 @@ class Player extends Component {
|
|||
this._current = data;
|
||||
}
|
||||
|
||||
async _dispatchSongChange(detail) {
|
||||
const app = await this._app;
|
||||
_dispatchSongChange(detail) {
|
||||
const e = new CustomEvent("song-change", {detail});
|
||||
app.dispatchEvent(e);
|
||||
this._app.dispatchEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,31 +1,38 @@
|
|||
import * as mpd from "./lib/mpd.js";
|
||||
import * as html from "./lib/html.js";
|
||||
import * as pubsub from "./lib/pubsub.js";
|
||||
import * as ui from "./lib/ui.js";
|
||||
|
||||
let node;
|
||||
import Component from "./component.js";
|
||||
|
||||
function buildLists(lists) {
|
||||
let ul = node.querySelector("ul");
|
||||
|
||||
class Playlists extends Component {
|
||||
handleEvent(e) {
|
||||
switch (e.type) {
|
||||
case "playlists-change":
|
||||
this._sync();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_onAppLoad() {
|
||||
this._app.addEventListener("playlists-change", this);
|
||||
}
|
||||
|
||||
_onComponentChange(c, isThis) {
|
||||
this.hidden = !isThis;
|
||||
if (isThis) { this._sync(); }
|
||||
}
|
||||
|
||||
async _sync() {
|
||||
let lists = await this._mpd.listPlaylists();
|
||||
this._buildLists(lists);
|
||||
}
|
||||
|
||||
_buildLists(lists) {
|
||||
let ul = this.querySelector("ul");
|
||||
html.clear(ul);
|
||||
|
||||
lists.map(list => ui.playlist(list, ul));
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLists() {
|
||||
let lists = await mpd.listPlaylists();
|
||||
buildLists(lists);
|
||||
}
|
||||
|
||||
function onPlaylistsChange(message, publisher, data) {
|
||||
syncLists();
|
||||
}
|
||||
|
||||
export async function activate() {
|
||||
syncLists();
|
||||
}
|
||||
|
||||
export function init(n) {
|
||||
node = n;
|
||||
pubsub.subscribe("playlists-change", onPlaylistsChange);
|
||||
}
|
||||
customElements.define("cyp-playlists", Playlists);
|
||||
|
|
|
@ -9,23 +9,15 @@ class Queue extends Component {
|
|||
this._currentId = null;
|
||||
|
||||
this.querySelector(".clear").addEventListener("click", async _ => {
|
||||
const mpd = await this._mpd;
|
||||
await mpd.command("clear");
|
||||
await this._mpd.command("clear");
|
||||
this._sync();
|
||||
});
|
||||
|
||||
this.querySelector(".save").addEventListener("click", async _ => {
|
||||
this.querySelector(".save").addEventListener("click", _ => {
|
||||
let name = prompt("Save current queue as a playlist?", "name");
|
||||
if (name === null) { return; }
|
||||
const mpd = await this._mpd;
|
||||
mpd.command(`save "${mpd.escape(name)}"`);
|
||||
this._mpd.command(`save "${this._mpd.escape(name)}"`);
|
||||
});
|
||||
|
||||
this._app.then(app => {
|
||||
app.addEventListener("song-change", this);
|
||||
app.addEventListener("queue-change", this);
|
||||
})
|
||||
this._sync();
|
||||
}
|
||||
|
||||
handleEvent(e) {
|
||||
|
@ -41,6 +33,12 @@ class Queue extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
_onAppLoad() {
|
||||
this._app.addEventListener("song-change", this);
|
||||
this._app.addEventListener("queue-change", this);
|
||||
this._sync();
|
||||
}
|
||||
|
||||
_onComponentChange(c, isThis) {
|
||||
this.hidden = !isThis;
|
||||
|
||||
|
@ -48,8 +46,7 @@ class Queue extends Component {
|
|||
}
|
||||
|
||||
async _sync() {
|
||||
const mpd = await this._mpd;
|
||||
let songs = await mpd.listQueue();
|
||||
let songs = await this._mpd.listQueue();
|
||||
this._buildSongs(songs);
|
||||
|
||||
// FIXME pubsub?
|
||||
|
@ -57,8 +54,7 @@ class Queue extends Component {
|
|||
}
|
||||
|
||||
_updateCurrent() {
|
||||
let all = Array.from(this.querySelectorAll("[data-song-id]"));
|
||||
all.forEach(node => {
|
||||
Array.from(this.querySelectorAll("[data-song-id]")).forEach(/** @param {HTMLElement} node */ node => {
|
||||
node.classList.toggle("current", node.dataset.songId == this._currentId);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -17,13 +17,24 @@ class Settings extends Component {
|
|||
theme: this.querySelector("[name=theme]"),
|
||||
color: Array.from(this.querySelectorAll("[name=color]"))
|
||||
};
|
||||
}
|
||||
|
||||
this._load();
|
||||
_onAppLoad() {
|
||||
let mo = new MutationObserver(mrs => {
|
||||
mrs.forEach(mr => this._onAppAttributeChange(mr));
|
||||
});
|
||||
mo.observe(this._app, {attributes:true});
|
||||
|
||||
this._inputs.theme.addEventListener("change", e => this._setTheme(e.target.value));
|
||||
this._inputs.color.forEach(input => {
|
||||
input.addEventListener("click", e => this._setColor(e.target.value));
|
||||
});
|
||||
|
||||
const theme = loadFromStorage("theme");
|
||||
(theme ? this._app.setAttribute("theme", theme) : this._syncTheme());
|
||||
|
||||
const color = loadFromStorage("color");
|
||||
(color ? this._app.setAttribute("color", color) : this._syncColor());
|
||||
}
|
||||
|
||||
_onAppAttributeChange(mr) {
|
||||
|
@ -31,39 +42,29 @@ class Settings extends Component {
|
|||
if (mr.attributeName == "color") { this._syncColor(); }
|
||||
}
|
||||
|
||||
async _syncTheme() {
|
||||
const app = await this._app;
|
||||
this._inputs.theme.value = app.getAttribute("theme");
|
||||
_syncTheme() {
|
||||
this._inputs.theme.value = this._app.getAttribute("theme");
|
||||
}
|
||||
|
||||
async _syncColor() {
|
||||
const app = await this._app;
|
||||
_syncColor() {
|
||||
this._inputs.color.forEach(input => {
|
||||
input.checked = (input.value == app.getAttribute("color"));
|
||||
input.checked = (input.value == this._app.getAttribute("color"));
|
||||
input.parentNode.style.color = input.value;
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
const app = await this._app;
|
||||
|
||||
const theme = loadFromStorage("theme");
|
||||
(theme ? app.setAttribute("theme", theme) : this._syncTheme());
|
||||
|
||||
const color = loadFromStorage("color");
|
||||
(color ? app.setAttribute("color", color) : this._syncColor());
|
||||
}
|
||||
|
||||
async _setTheme(theme) {
|
||||
const app = await this._app;
|
||||
_setTheme(theme) {
|
||||
saveToStorage("theme", theme);
|
||||
app.setAttribute("theme", theme);
|
||||
this._app.setAttribute("theme", theme);
|
||||
}
|
||||
|
||||
async _setColor(color) {
|
||||
const app = await this._app;
|
||||
_setColor(color) {
|
||||
saveToStorage("color", color);
|
||||
app.setAttribute("color", color);
|
||||
this._app.setAttribute("color", color);
|
||||
}
|
||||
|
||||
_onComponentChange(c, isThis) {
|
||||
this.hidden = !isThis;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import * as mpd from "./lib/mpd.js";
|
||||
import * as html from "./lib/html.js";
|
||||
import * as pubsub from "./lib/pubsub.js";
|
||||
import * as ui from "./lib/ui.js";
|
||||
import * as conf from "./conf.js";
|
||||
|
||||
let node;
|
||||
|
|
Loading…
Reference in a new issue